Freeradius 3.2.10 配置文件说明以及一个企业级网络认证案例

总体概览

FreeRADIUS 3.2.10 的配置设计看似繁杂,但核心逻辑非常明确:将工具定义(Modules)、接入客户端(Clients)和业务流程(Sites)彻底解耦。如果把 FreeRADIUS 类比为一个 Spring Boot 框架:

  • radiusd.conf = application.yml(全局基础配置、线程池、环境变量)
  • clients.conf = 防火墙/白名单(允许哪些 NAS/交换机/AP 连进来)
  • mods-enabled/ = Service 层(具体干活的插件/工具,如数据库连接、REST 调用、密码哈希)
  • sites-enabled/ = Controller + Interceptor(定义请求进来后的执行管道与路由)

整体结构鸟瞰图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/etc/freeradius/
├── radiusd.conf <-- [全局] 主配置文件(全局参数、线程池、主日志)
├── clients.conf <-- [安全] 允许接入的 NAS 交换机 / AP / 网关列表

├── mods-available/ <-- [模块库] 放置所有可用的功能模块定义文件
├── mods-enabled/ <-- [已激活模块] 软链接指向 mods-available/
│ ├── rest <-- REST 模块配置 (定义 URI、JSON 属性映射)
│ ├── sql <-- SQL 模块配置 (数据库连接池、SQL 语句)
│ ├── pap / chap / eap <-- 各类加密/认证算法模块

├── sites-available/ <-- [流程库] 放置所有可用的虚拟服务器(流水线)
└── sites-enabled/ <-- [已激活流程] 软链接指向 sites-available/
├── default <-- 主流水线 (监听 1812/1813,处理明文/常规请求)
│ ├── authorize { ... } <-- 阶段 1: 预检查、调 rest/sql、设定 Auth-Type
│ ├── authenticate { ... }<-- 阶段 2: 根据 Auth-Type 执行密码比对
│ └── post-auth { ... } <-- 阶段 3: 认证成功后下发属性 (如 VLAN)

└── inner-tunnel <-- 隧道内部流水线 (处理 802.1X PEAP 隧道解密后的请求)


radiusd.conf

整个 FreeRADIUS 服务的入口配置文件。它定义了服务的全局行为:

  • 运行用户与组:user = freeradius / group = freeradius。
  • 主日志配置:log { destination = files, file = ${logdir}/radius.log }。
  • 线程池参数:thread pool { start_servers = 5, max_servers = 32 }。
  • 文件引入($INCLUDE):负责将 clients.conf、mods-enabled/、sites-enabled/ 等零散文件拼装成完整运行上下文。


clients.conf

定义允许与 RADIUS 服务器通信的客户端设备(Network Access Server,如华为/华三交换机、无线 AP、VPN 网关或测试工具 radtest)。

1
2
3
4
5
client local_network {
ipaddr = 192.168.1.0/24 # 允许请求的 IP 段
secret = testing123 # RADIUS 共享密钥 (Shared Secret)
shortname = dev-switches
}


模块层配置

FreeRADIUS 采用 “可用” 与 “启用” 分离的策略:所有功能模块配置文件存放在 mods-available/ 中,当需要使用某个功能时,在 mods-enabled/ 下建一个同名软链接即可。

核心常见模块一览:

模块名称 作用说明 常见应用场景
rest 发送 HTTP GET/POST/PUT 请求与外部 REST API 交互 对接 Spring Boot、Go 或微服务后端
sql 直接连接 MySQL / PostgreSQL 数据库 读取 radcheck、radreply 表或记录计费日志
pap 处理明文密码认证 最基础的密码比对
chap 处理 CHAP 挑战应答式认证 PPPoE、部分 VPN 认证
mschap 处理 MS-CHAPv1/v2 认证 结合 Samba/AD 做 Windows 域认证或 VPN 认证
eap 处理 802.1X / EAP 框架(PEAP, EAP-TLS, EAP-TTLS) 企业级 WiFi / 园区网有线准入
files 读取本地静态文本文件(/etc/freeradius/users) 简易测试或静态规则兜底

以 mods-enabled/rest 为例,REST 模块内部又按 FreeRADIUS 的生命周期切分了不同的 HTTP 映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
rest {
connect_uri = "http://192.168.1.3:8080/api/v1/radius"

# 在 authorize 阶段要调用的 HTTP 接口
authorize {
uri = "${..connect_uri}/check-asset"
method = 'post'
body = 'json'
data = '{"username": "%{User-Name}", "mac": "%{Calling-Station-Id}"}'
}

# 在 authenticate 阶段要调用的 HTTP 接口
authenticate rest_pap {
uri = "${..connect_uri}/auth-pap"
method = 'post'
body = 'json'
data = '{"username": "%{User-Name}", "password": "%{User-Password}"}'
}
}


站点层配置

两个主要配置文件

站点代表一个虚拟服务器(Virtual Server),也是请求真正的处理流水线。两个核心默认站点:

  • default:监听 1812(认证)与 1813(计费)端口,处理绝大部分未加密/单层的常规 RADIUS 请求(如 PAP、CHAP、MAC 准入)。
  • inner-tunnel:不直接暴露公网端口,只在内部被 eap 模块调用。当进行 802.1X PEAP 认证时,外层建立 TLS 隧道后,剥离出来的内层明文请求会送入 inner-tunnel 处理。


五大阶段

在一个站点文件(如 sites-enabled/default)中,请求会按顺序穿过以下 5 个主要阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
[ 客户端请求入站 ]


1. authorize ──► 决定“能否接入”及“用什么方式认证”


2. authenticate ──► 根据 Auth-Type 校验凭据/密码

├───────────────────────┐
▼ (认证成功) ▼ (认证失败)
3. post-auth 4. Post-Auth-Type REJECT
│ │
└───────────────────────┴──► 返回 Access-Accept / Reject

authorize(授权与准备阶段)

  • 任务:解析请求、提取属性、调用外部查询(SQL/REST)、设置控制标记(Auth-Type)。

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    authorize {
    filter_username # 清理用户名格式
    preprocess # 处理成标准的 RADIUS 属性

    rest # 1. 发 HTTP POST /check-asset 检查资产

    # 2. 如果 REST 返回 200,打上 rest_pap 的认证通道标记
    update control {
    &Auth-Type := rest_pap
    }
    }

authenticate(认证比对阶段)

  • 任务:仅在 authorize 阶段设置了 Auth-Type 或请求包含特定的加密响应(如 CHAP Challenge)时触发,负责验证密码是否正确。

  • 典型代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    authenticate {
    # 对应上面设置的 Auth-Type := rest_pap
    Auth-Type rest_pap {
    rest # 调用 mods-enabled/rest 模块里的 authenticate rest_pap 节点
    }

    Auth-Type PAP {
    pap # 本地 PAP 比对
    }
    }

post-auth(认证成功后处理)

  • 任务:当认证通过时触发。通常用来给客户端下发 VLAN ID、分配固定 IP、或者记录日志/写数据库。

  • 典型代码:

    1
    2
    3
    4
    5
    6
    7
    post-auth {
    update reply {
    Tunnel-Type = VLAN,
    Tunnel-Medium-Type = IEEE-802,
    Tunnel-Private-Group-Id = "100" # 动态下发 VLAN 100
    }
    }

accounting(计费阶段)

  • 任务:处理客户端发来的 Accounting-Request 报文(Start / Interim-Update / Stop),记录上下线时间与流量统计。

pre-proxy / post-proxy(代理转发阶段)

  • 任务:当 RADIUS 充当代理服务器(Proxy)需要将请求转发给上级 RADIUS 时使用。


企业典型案例(REST 前置)

具体需求

下面我们来实现一个真正的企业级案例,这个案例是典型的企业级零信任准入系统架构,通过 FreeRADIUS 作为统一准入网关,将一切身份鉴权、设备资产校验和 VLAN 动态授权全量收口到后端 Spring Boot REST 服务。它的具体要求如下:

  1. 支持普通的 PAP 、CHAP 认证
  2. 支持 EAP-TTLS + PAP 基于用户密码的认证方式
  3. 支持 EAP-TLS 证书认证
  4. 支持哑终端的 mac 认证
  5. 支持 portal server PAP 和 CHAP 认证
  6. 所有认证过程都需要走 REST 模块。
  7. 数据源接入 mysql


Freeradius 配置实现

Freeradius 3.2.10 测试环境的快速构建可以参考之前的两篇文章 :《Freeradius 3.2.10 环境搭建以及 PAP 和 CHAP 两种认证方式的测试》《Freeradius 3.2.10 基于密码的企业级安全认证实现》,这里不再赘述。

第一,/etc/freeradius/clients.conf:定义允许接入 FreeRADIUS 的交换机/AP/Portal 网关(也可以使用 nas 表代替)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 允许本地测试工具(如 radtest / radclient)
client localhost {
ipaddr = 127.0.0.1
proto = *
secret = testing123
nas_type = other # localhost isn't usually a NAS...
limit {
max_connections = 16
lifetime = 0
idle_timeout = 900
}
}

client localhost_ipv6 {
ipv6addr = ::1
secret = testing123
}

# 允许内网测试工具接入
client docker_net {
ipaddr = 172.0.0.0/8
secret = testing123
}

# 允许企业内网所有交换机、AP 及 Portal 网关接入
client enterprise_network {
ipaddr = 192.168.0.0/16
secret = radius_secret_2026
shortname = ent-switches
}

第二,/etc/freeradius/mods-enabled/sql。配置 MySQL 数据库连接池(处理系统基础数据或备用账密)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

sql {
dialect = "mysql"
driver = "rlm_sql_mysql"

mysql {
warnings = auto
}

server = "192.168.1.251"
port = 3306
login = "xxx"
password = "xxx"
radius_db = "radius3"

acct_table1 = "radacct"
acct_table2 = "radacct"
postauth_table = "radpostauth"
authcheck_table = "radcheck"
groupcheck_table = "radgroupcheck"
authreply_table = "radreply"
groupreply_table = "radgroupreply"
usergroup_table = "radusergroup"

delete_stale_sessions = yes

pool {
start = ${thread[pool].start_servers}
min = ${thread[pool].min_spare_servers}
max = ${thread[pool].max_servers}
spare = ${thread[pool].max_spare_servers}
uses = 0
retry_delay = 30
lifetime = 0
idle_timeout = 60
max_retries = 5
}

read_clients = yes
client_table = "nas"
group_attribute = "SQL-Group"

$INCLUDE ${modconfdir}/${.:name}/main/${dialect}/queries.conf
}

第三,/etc/freeradius/mods-enabled/eap。配置 EAP-TLS(证书认证) 与 EAP-TTLS(外层 TLS 隧道 + 内层 PAP)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
eap {
default_eap_type = ttls
timer_expire = 60
ignore_unknown_eap_types = no
cisco_accounting_username_bug = no
max_sessions = ${max_requests}

# TLS 基础配置(用于 EAP-TLS 认证及 EAP-TTLS 的外层加密)
tls-config tls-common {
# server.key 的查看密码
private_key_password = whatever
private_key_file = ${certdir}/server.key
certificate_file = ${certdir}/server.pem
ca_file = ${cadir}/ca.pem
ca_path = ${cadir}

# 提取客户端证书序列号/指纹,用于 REST 端进行设备证书唯一编号校验
tls_min_version = "1.2"
cipher_list = "DEFAULT@SECLEVEL=1"
#make_cert_command = "${certdir}/gen-user-cert.sh %{User-Name}"
}

# 1. 支持 EAP-TLS 证书认证
tls {
tls = tls-common
}

# 2. 支持 EAP-TTLS 认证
ttls {
tls = tls-common
default_eap_type = pap
# 核心:将解密后的内层 PAP 属性拷贝并发送给 inner-tunnel 虚拟服务器
copy_request_to_tunnel = yes
use_tunneled_reply = yes
virtual_server = "inner-tunnel"
}

peap {
tls = tls-common
default_eap_type = mschapv2
copy_request_to_tunnel = no
use_tunneled_reply = no
#proxy_tunneled_request_as_eap = yes
virtual_server = "inner-tunnel"
}
}

第四,/etc/freeradius/mods-enabled/rest。统一定义传给 Spring Boot 后端的全量参数(包含用户、密码、MAC 地址、设备编号、NAS IP、证书序列号、认证类型等),并从 REST 响应中提取 VLAN-ID 进行动态授权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
rest {
# REST 服务器
connect_uri = "http://192.168.1.3:8080/api/v1/radius"

# 统一 HTTP 请求头
tls {
timeout = 10
}

# ------------------------------------------------------------------
# 1. 资产与权限校验 (authorize 阶段调用)
# 支持 PAP/CHAP/MAC/Portal/EAP-TLS/EAP-TTLS 资产统一审查
# ------------------------------------------------------------------
authorize {
uri = "${..connect_uri}/check-asset"
method = 'post'
body = 'json'
data = '{\
"username": "%{User-Name}",\
"chapPassword": "%{CHAP-Password}",\
"mac": "%{Calling-Station-Id}",\
"nasIp": "%{NAS-IP-Address}",\
"nasPort": "%{NAS-Port}",\
"authType": "%{control:Auth-Type}",\
"certSerialNumber": "%{TLS-Client-Cert-Serial}",\
"nasPortType": "%{NAS-Port-Type}"\
}'

# 设置为 json,FreeRADIUS 会自动将上述返回的 JSON 转换为 RADIUS reply 属性!
response = 'json'
}

# ------------------------------------------------------------------
# 2. PAP 明文密码校验 (适用于 PAP, Portal PAP, EAP-TTLS+PAP)
# ------------------------------------------------------------------
authenticate rest_pap {
uri = "${..connect_uri}/auth-pap"
method = 'post'
body = 'json'
data = '{\
"username": "%{User-Name}",\
"password": "%{User-Password}",\
"mac": "%{Calling-Station-Id}",\
"nasIp": "%{NAS-IP-Address}"\
}'
}

preacct {
uri = "${..connect_uri}/user/%{User-Name}/sessions/%{Acct-Unique-Session-ID}?action=preacct"
method = 'post'
tls = ${..tls}
}
accounting {
uri = "${..connect_uri}/user/%{User-Name}/sessions/%{Acct-Unique-Session-ID}?action=accounting"
method = 'post'
tls = ${..tls}
}
post-auth {
uri = "${..connect_uri}/user/%{User-Name}/mac/%{Called-Station-ID}?action=post-auth"
method = 'post'
tls = ${..tls}
}
pre-proxy {
uri = "${..connect_uri}/user/%{User-Name}/mac/%{Called-Station-ID}?action=pre-proxy"
method = 'post'
tls = ${..tls}
}
post-proxy {
uri = "${..connect_uri}/user/%{User-Name}/mac/%{Called-Station-ID}?action=post-proxy"
method = 'post'
tls = ${..tls}
}

xlat {
body_uri_encode = yes
tls = ${..tls}
}

pool {
start = ${thread[pool].start_servers}
min = ${thread[pool].min_spare_servers}
max = ${thread[pool].max_servers}
spare = ${thread[pool].max_spare_servers}
uses = 0
retry_delay = 30
lifetime = 0
idle_timeout = 60
}
}

第五,/etc/freeradius/sites-enabled/default。主虚拟服务器逻辑:识别 PAP/CHAP/Portal/MAC/EAP-TLS/EAP-TTLS 请求,并路由到 REST 校验和 VLAN 下发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
server default {
listen {
type = auth
ipaddr = *
port = 1812
limit {
max_connections = 16
lifetime = 0
idle_timeout = 900
}
}

listen {
type = acct
ipaddr = *
port = 1813
}

# ------------------------------------------------------------------
# 授权与预处理逻辑:从前往后执行
# ------------------------------------------------------------------
authorize {
filter_username
preprocess

# 哑终端 MAC 准入识别:如果 User-Name 等于 MAC 地址,识别为 MAC 认证
if (User-Name == Calling-Station-Id) {
update control {
Auth-Type := Accept
}
}

# 优先处理 EAP (EAP-TLS / EAP-TTLS)
eap {
ok = return
}

# 统一调用 REST 模块做设备资产校验(入参包含账号/MAC/IP/证书等)
rest

# 判断 PAP 与 CHAP 流程,并标记 Auth-Type 供 authenticate 节路由
if (User-Password) {
update control {
Auth-Type := rest_pap
}
}

# 补丁:把 rest 返回的 Cleartext-Password 从 reply 复制到 control 列表,并从 reply 中抹除
if (reply:Cleartext-Password) {
update control {
&Cleartext-Password := "%{reply:Cleartext-Password}"
}
update reply {
&Cleartext-Password !* ANY
}
}

# 如果是 CHAP 认证(有 CHAP-Password),CHAP 的 MD5 哈希校验必须由 FreeRADIUS 本身(chap 模块)在本地完成。
# 此时 control:Cleartext-Password 已经到位了,只要控制列表中有了 Cleartext-Password,系统会自动转交给内置的 chap 模块处理。
if (CHAP-Password) {
chap
}
}

# ------------------------------------------------------------------
# 凭据比对认证逻辑
# ------------------------------------------------------------------
authenticate {
# 1. PAP 认证 (普通 PAP / Portal PAP)
Auth-Type rest_pap {
rest
}

# 2. CHAP 走 FreeRADIUS 原生 MD5 比对模块
# CHAP 模式:走原生 chap 模块(使用 /check-asset 返回的 Cleartext-Password 在本地比对)
Auth-Type CHAP {
chap
}

# 3. EAP 框架认证 (EAP-TLS / EAP-TTLS)
eap

# 4. MAC 校验直接通过授权 (已在 authorize 中经 REST 校验资产)
Auth-Type Accept {
pap
}
}

# ------------------------------------------------------------------
# 3. 计费预处理阶段 (保留原生)
# ------------------------------------------------------------------
preacct {
preprocess
acct_unique
suffix
files
}

# ------------------------------------------------------------------
# 4. 计费处理阶段 (保留原生)
# ------------------------------------------------------------------
accounting {
detail
-sql
exec
attr_filter.accounting_response
}

session {
}

# ------------------------------------------------------------------
# 5. 认证通过/拒绝后处理阶段 (融合原生与动态 VLAN)
# ------------------------------------------------------------------
post-auth {
# 原生 Session 与属性恢复逻辑
if (session-state:User-Name && reply:User-Name && request:User-Name && (reply:User-Name == request:User-Name)) {
update reply {
&User-Name !* ANY
}
}
update {
&reply: += &session-state:
}

# 自定义逻辑:如果 REST 模块成功解析到了 vlanId,补充下发 802.1Q 标准 VLAN(RFC 3580 标准)
if (reply:Tunnel-Private-Group-Id) {
update reply {
&Tunnel-Type := VLAN
&Tunnel-Medium-Type := IEEE-802
}
}

-sql
exec

# EAP 握手收尾与密钥派生 (必不可少)
eap

remove_reply_message_if_eap

Post-Auth-Type REJECT {
-sql
attr_filter.access_reject
eap
remove_reply_message_if_eap
}

Post-Auth-Type Challenge {
}

Post-Auth-Type Client-Lost {
}

if (EAP-Key-Name && &reply:EAP-Session-Id) {
update reply {
&EAP-Key-Name := &reply:EAP-Session-Id
}
}
}

pre-proxy {
}

post-proxy {
}
}

第六,/etc/freeradius/sites-enabled/inner-tunnel。内层隧道:处理 EAP-TTLS 剥离 TLS 隧道 后的内层 PAP 认证请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
server inner-tunnel {

listen {
ipaddr = 127.0.0.1
port = 18120
type = auth
}

# ------------------------------------------------------------------
# 1. 内层授权与预处理阶段
# ------------------------------------------------------------------
authorize {
filter_username
suffix

# 支持 EAP 框架内部协商
eap {
ok = return
}

# 调用 REST 模块对内层账户/资产进行统一审查
rest

# 针对 EAP-TTLS + PAP,将明文密码请求路由至 REST PAP 分支
if (&User-Password) {
update control {
Auth-Type := rest_pap
}
}

# 补丁:把 rest 返回的 Cleartext-Password 从 reply 复制到 control 列表,并从 reply 中抹除
if (reply:Cleartext-Password) {
update control {
&Cleartext-Password := "%{reply:Cleartext-Password}"
}
update reply {
&Cleartext-Password !* ANY
}
}

# 此时 control:Cleartext-Password 已经到位了,只要控制列表中有了 Cleartext-Password,系统会自动转交给内置的 chap 模块处理。
if (&CHAP-Password) {
chap
}

# 本地与备用数据源/模块 (按需保留)
files
-sql
}

# ------------------------------------------------------------------
# 2. 内层凭据比对认证阶段
# ------------------------------------------------------------------
authenticate {
# 自定义 REST PAP 密码校验
Auth-Type rest_pap {
rest
}

# CHAP 模式:走原生 chap 模块(使用 /check-asset 返回的 Cleartext-Password 在本地比对)
Auth-Type CHAP {
chap
}

# 保留原生认证模块兜底
Auth-Type PAP {
pap
}

Auth-Type CHAP {
chap
}

Auth-Type MS-CHAP {
mschap
}

mschap
eap
}

session {
}

# ------------------------------------------------------------------
# 3. 内层认证通过/拒绝后处理阶段
# ------------------------------------------------------------------
post-auth {
-sql

# 核心逻辑:如果 REST 模块返回了 VLAN ID,将其复制到 outer.session-state
# 这样外层的 default 站点才能拿到 VLAN 并下发给交换机/AP
if (reply:Tunnel-Private-Group-Id) {
update outer.session-state {
&Tunnel-Private-Group-Id := "%{reply:Tunnel-Private-Group-Id}"
&Tunnel-Type := VLAN
&Tunnel-Medium-Type := IEEE-802
}
}

# 保留原生的 session-state 传导机制
update {
&outer.session-state: += &reply:
}

Post-Auth-Type REJECT {
-sql
attr_filter.access_reject

update outer.session-state {
&Module-Failure-Message := &request:Module-Failure-Message
}
}
}

pre-proxy {
}

post-proxy {
}
}


Mysql 表和数据准备

这部分内容可以参考 EAP-TTLS + PAP 建库建表。另外,为了测试 chap 情况,我们也需要在数据库中再插入一条数据。账号只在数据库存在,其他之前配置的,比如 users 配置文件的中的用户数据全部删除。

1
2
3
4
INSERT INTO radius3.radcheck(username, attribute, op, value) VALUES ('owlias01', 'Crypt-Password', ':=', '$2b$12$1dKv2bZWGBPN58J1R4hFBODkMY1S.UhVYQKbqE5IiVO.jDkizVdLe');

INSERT INTO radcheck (username, attribute, op, value)
VALUES ('owlias_chap', 'Cleartext-Password', ':=', '1234567');


REST 服务实现

依赖配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.3.0</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>6.3.0</version>
</dependency>

<!-- 硬件信息采集 (可选: 仅在需要读取本机 SN 时使用) -->
<dependency>
<groupId>com.github.oshi</groupId>
<artifactId>oshi-core</artifactId>
<version>7.2.0</version>
</dependency>
</dependencies>

启动类和配置文件:

1
2
3
4
5
6
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
1
2
3
4
5
6
7
8
9
server:
port: 8080

spring:
datasource:
url: jdbc:mysql://192.168.1.251:3306/radius3?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: xxx
password: xxx
driver-class-name: com.mysql.cj.jdbc.Driver

业务控制器:RadiusController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@Slf4j
@RestController
@RequestMapping("/api/v1/radius")
public class RadiusController {

@Resource
private RadiusAuthService radiusAuthService;

/**
* 1. 资产与权限校验 (FreeRADIUS authorize 阶段)
*/
@PostMapping("/check-asset")
public ResponseEntity<RadiusCheckAssetResp> checkAsset(@RequestBody RadiusCheckAssetReq req) {
try {
// 1. 校验资产与用户准入权限并获取 VLAN
String vlanId = radiusAuthService.checkAssetAndGetVlan(req);
if (vlanId == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}

// 2. 如果是 CHAP 请求,或者数据库中有该用户的 Cleartext-Password,提前查出返回给 FreeRADIUS
String userCleartextPassword = "";
if (Objects.nonNull(req.getChapPassword())) {
userCleartextPassword = radiusAuthService.getUserCleartextPassword(req.getUsername())
.orElse("");
}

// 3. 构造返回结构
RadiusCheckAssetResp resp = RadiusCheckAssetResp.builder()
.tunnelType(List.of("VLAN"))
.tunnelMediumType(List.of("IEEE-802"))
.tunnelPrivateGroupId(List.of(vlanId))
.cleartextPassword(userCleartextPassword.isBlank()
? Collections.emptyList()
: Collections.singletonList(userCleartextPassword))
.build();
return ResponseEntity.ok(resp);
} catch (Exception e) {
log.error("资产检查处理异常", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}

/**
* 2. PAP 认证校验 (FreeRADIUS authenticate rest_pap 阶段)
*/
@PostMapping("/auth-pap")
public ResponseEntity<?> authPap(@RequestBody RadiusAuthPapReq req) {
boolean passed = radiusAuthService.authenticatePap(req);
if (passed) {
return ResponseEntity.ok().build(); // HTTP 200 表示认证密码通过
} else {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); // HTTP 401 触发 Access-Reject
}
}

// =========================================================================
// 4. 生命周期/事件钩子 (FreeRADIUS 默认定义的 preacct/accounting/post-auth 等)
// 地址格式: /user/{username}/sessions/{sessionId}?action=preacct 等
// =========================================================================

@PostMapping("/user/{username}/sessions/{sessionId}")
public ResponseEntity<?> handleSessionEvent(
@PathVariable("username") String username,
@PathVariable("sessionId") String sessionId,
@RequestParam("action") String action,
@RequestBody(required = false) Map<String, Object> body) {
log.info("[RADIUS 会话事件] action={}, username={}, sessionId={}, body={}", action, username, sessionId, body);
// 可根据 action ("preacct", "accounting") 处理计费日志记录、计算流量、记录上线/下线
return ResponseEntity.ok().build();
}

@PostMapping("/user/{username}/mac/{mac}")
public ResponseEntity<?> handleMacEvent(
@PathVariable("username") String username,
@PathVariable("mac") String mac,
@RequestParam("action") String action,
@RequestBody(required = false) Map<String, Object> body) {
log.info("[RADIUS 设备事件] action={}, username={}, mac={}, body={}", action, username, mac, body);
// 可根据 action ("post-auth", "pre-proxy", "post-proxy") 记录上下线审计审计日志
return ResponseEntity.ok().build();
}
}

请求和响应的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Data
public class RadiusCheckAssetReq {
private String username;
private String chapPassword;
private String mac;
private String nasIp;
private String nasPort;
private String authType;
private String certSerialNumber;
private String nasPortType;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RadiusCheckAssetResp {
// 映射 RADIUS 标准属性:Tunnel-Type = VLAN
@JsonProperty("Tunnel-Type")
private List<String> tunnelType;

// 映射 RADIUS 标准属性:Tunnel-Medium-Type = IEEE-802
@JsonProperty("Tunnel-Medium-Type")
private List<String> tunnelMediumType;

// 映射 RADIUS 标准属性:Tunnel-Private-Group-Id = VLAN ID (如 "100")
@JsonProperty("Tunnel-Private-Group-Id")
private List<String> tunnelPrivateGroupId;

// 用户的明文密码(专用于 CHAP 认证模式,供 FreeRADIUS 本地计算 CHAP-MD5)
@JsonProperty("Cleartext-Password")
private List<String> cleartextPassword;
}

@Data
public class RadiusAuthPapReq {
private String username;
private String password;
private String mac;
private String nasIp;
}

业务接口和实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface RadiusAuthService {

/**
* 校验设备资产合法性,并决定返回的 VLAN ID
*/
String checkAssetAndGetVlan(RadiusCheckAssetReq req);

/**
* PAP 明文密码校验
*/
boolean authenticatePap(RadiusAuthPapReq req);

/**
* 为 CHAP 认证提前获取用户明文密码
*/
Optional<String> getUserCleartextPassword(String username);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@Slf4j
@Service
public class RadiusAuthServiceImpl implements RadiusAuthService {

@Resource
private RadCheckRepository radCheckRepository;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

@Override
public String checkAssetAndGetVlan(RadiusCheckAssetReq req) {
log.info("[RADIUS 资产校验] 收到请求: req={}", req);

// 1. MAC 准入 / 哑终端逻辑判定
if ("Accept".equalsIgnoreCase(req.getAuthType()) || req.getUsername().equalsIgnoreCase(req.getMac())) {
log.info("[RADIUS 资产校验] 哑终端 MAC 认证通过, mac={}", req.getMac());
return "102"; // 哑终端专属 VLAN
}

// 2. 证书认证 (EAP-TLS) 逻辑判定
if (req.getCertSerialNumber() != null && !req.getCertSerialNumber().isBlank()) {
log.info("[RADIUS 资产校验] 证书认证通过, sn={}", req.getCertSerialNumber());
return "200"; // 极高安全 VLAN
}

// 3. 校验用户是否存在于 MySQL 数据库(若用户不存在则直接拒绝准入)
List<RadCheck> userAttributes = radCheckRepository.findByUsername(req.getUsername());
if (userAttributes.isEmpty()) {
log.warn("[RADIUS 资产校验失败] 用户在数据库中不存在: username={}", req.getUsername());
return null; // 返回 null 触发 Controller 403 Reject
}

// 4. 普通用户/办公网默认 VLAN
log.info("[RADIUS 资产校验成功] username={}", req.getUsername());
return "100";
}

@Override
public boolean authenticatePap(RadiusAuthPapReq req) {
log.info("[RADIUS PAP 认证] 收到 PAP 密码比对请求: req={}",req);

List<RadCheck> checks = radCheckRepository.findByUsername(req.getUsername());
if (checks.isEmpty()) {
log.warn("[RADIUS PAP 认证失败] 用户不存在: username={}", req.getUsername());
return false;
}
String rawInputPassword = req.getPassword();

for (RadCheck check : checks) {
String attr = check.getAttribute();
String dbVal = check.getValue();

// 1. 处理 Crypt-Password (如 BCrypt 存储的密码:以 $2a$, $2b$, $2y$ 开头)
if ("Crypt-Password".equalsIgnoreCase(attr)) {
if (dbVal.startsWith("$2a$") || dbVal.startsWith("$2b$") || dbVal.startsWith("$2y$")) {
// 标准 BCrypt 加密方式的判定,使用 BCrypt 进行哈希校验
if (passwordEncoder.matches(rawInputPassword, dbVal)) {
log.info("[RADIUS PAP 认证成功] BCrypt 匹配: username={}", req.getUsername());
return true;
}
} else {
// 如果是 Unix crypt/DES 或其他格式,可根据需要扩充
log.warn("[RADIUS PAP 认证] 暂不支持的 Crypt-Password 格式: {}", dbVal);
}
}
// 2. 明文密码比对
else if ("Cleartext-Password".equalsIgnoreCase(attr) || "User-Password".equalsIgnoreCase(attr)) {
if (dbVal.equals(rawInputPassword)) {
log.info("[RADIUS PAP 认证成功] 明文匹配: username={}", req.getUsername());
return true;
}
}
// 3. MD5-Password 密文比对 (标准 MD5 Hex)
else if ("MD5-Password".equalsIgnoreCase(attr)) {
String inputMd5 = DigestUtils.md5DigestAsHex(rawInputPassword.getBytes(StandardCharsets.UTF_8));
if (dbVal.equalsIgnoreCase(inputMd5)) {
log.info("[RADIUS PAP 认证成功] MD5 匹配: username={}", req.getUsername());
return true;
}
}
}

log.warn("[RADIUS PAP 认证失败] 密码比对不匹配: username={}", req.getUsername());
return false;
}

@Override
public Optional<String> getUserCleartextPassword(String username) {
// 专供 CHAP / authorize 阶段使用,从 JDBC 获取数据库中配置的明文密码
return radCheckRepository.findCleartextPasswordByUsername(username);
}
}

数据库 Repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Repository
public interface RadCheckRepository extends CrudRepository<RadCheck, Long> {
/**
* 根据用户名查询所有控制属性(包含 Cleartext-Password, MD5-Password, Crypt-Password 等)
* 简写方式:直接利用 Spring Data 派生方法名生成 SQL(甚至无需写 @Query
* SELECT id, username, attribute, op, value FROM radcheck WHERE username = ?
*/
List<RadCheck> findByUsername(String username);

/**
* 查出用户的明文密码(若数据库存的是 Cleartext-Password 或 User-Password)
*/
@Query("SELECT value FROM radcheck WHERE username = :username AND attribute IN ('Cleartext-Password', 'User-Password') ORDER BY id DESC LIMIT 1")
Optional<String> findCleartextPasswordByUsername(@Param("username") String username);
}

实体类 PO:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("radcheck")
public class RadCheck {
@Id
private Long id;
private String username;
private String attribute;
private String op;
private String value;
}


测试验证

测试对 PAP 认证的支持,在 radius-client 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ echo "User-Name = owlias01, User-Password = 123456, Calling-Station-Id = 00-11-22-33-44-55, NAS-IP-Address = 172.18.0.2" | radclient -x 172.18.0.3:1812 auth testing123
# 或者
$ radtest -t pap owlias01 123456 172.18.0.3:1812 0 testing123

Sent Access-Request Id 244 from 0.0.0.0:48710 to 172.18.0.3:1812 length 91
User-Name = "owlias01"
User-Password = "123456"
Calling-Station-Id = "00-11-22-33-44-55"
NAS-IP-Address = 172.18.0.2
Cleartext-Password = "123456"
Received Access-Accept Id 244 from 172.18.0.3:1812 to 172.18.0.2:48710 length 55
Message-Authenticator = 0xf2614ab62d71f981bc8b6bed680e151f
Tunnel-Type:0 = VLAN
Tunnel-Medium-Type:0 = IEEE-802
Tunnel-Private-Group-Id:0 = "100"

测试对 CHAP 认证的支持,在 radius-client 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ radtest -t chap owlias_chap 1234567 172.18.0.3:1812 0 testing123

Sent Access-Request Id 246 from 0.0.0.0:33841 to 172.18.0.3:1812 length 82
User-Name = "owlias_chap"
CHAP-Password = 0xd3fac3c260bb45733b7534a8bb1b480054
NAS-IP-Address = 172.18.0.2
NAS-Port = 0
Message-Authenticator = 0x00
Cleartext-Password = "1234567"
Received Access-Accept Id 246 from 172.18.0.3:1812 to 172.18.0.2:33841 length 55
Message-Authenticator = 0x4b5d91e24f1a03853f73d1ddd4bbbeec
Tunnel-Type:0 = VLAN
Tunnel-Medium-Type:0 = IEEE-802
Tunnel-Private-Group-Id:0 = "100"

测试对 TTLS+PAP 认证的支持,在 radius-client 执行:

1
2
3
4
5
6
7
$ eapol_test -c ttls-pap.conf -a 172.18.0.3 -p 1812 -s testing123

...
WPA: Clear old PMK and PTK
EAP: deinitialize previously used EAP method (21, TTLS) at EAP deinit
MPPE keys OK: 1 mismatch: 0
SUCCESS

测试对 EAP-TLS 认证的支持,先将一个测试证书 client.key 和 client.pem 复制到测试机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ docker cp ./client.key radius-client:/root/test/
$ docker cp ./client.pem radius-client:/root/test/
$ docker exec -it radius-client bash

cd /root/test
vim eap-tls.conf
network={
ssid="Corporate-WiFi"
key_mgmt=IEEE8021X
eap=TLS
identity="owlias01"
ca_cert="/root/test/ca.pem"
client_cert="/root/test/client.pem"
private_key="/root/test/client.key"
private_key_passwd="whatever"
}

# 语法:eapol_test -c <配置文件> -a <RADIUS服务器IP> -p <端口> -s <共享密钥>
eapol_test -c eap-tls.conf -a 172.18.0.3 -p 1812 -s testing123

...
MPPE keys OK: 1 mismatch: 0
SUCCESS

再来看 springboot REST 服务的日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
20:36.070+08:00  INFO 48897 --- [nio-8080-exec-7] z.radius.service.RadiusAuthServiceImpl   : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.074+08:00 INFO 48897 --- [nio-8080-exec-7] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01
20:36.092+08:00 INFO 48897 --- [nio-8080-exec-8] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.097+08:00 INFO 48897 --- [nio-8080-exec-8] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01
20:36.108+08:00 INFO 48897 --- [nio-8080-exec-9] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.115+08:00 INFO 48897 --- [nio-8080-exec-9] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01
20:36.127+08:00 INFO 48897 --- [io-8080-exec-10] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.132+08:00 INFO 48897 --- [io-8080-exec-10] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01
20:36.139+08:00 INFO 48897 --- [nio-8080-exec-1] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.142+08:00 INFO 48897 --- [nio-8080-exec-1] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01
20:36.165+08:00 INFO 48897 --- [nio-8080-exec-2] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.169+08:00 INFO 48897 --- [nio-8080-exec-2] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01
20:36.177+08:00 INFO 48897 --- [nio-8080-exec-3] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.180+08:00 INFO 48897 --- [nio-8080-exec-3] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01
20:36.190+08:00 INFO 48897 --- [nio-8080-exec-4] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验] 收到请求: req=RadiusCheckAssetReq(username=owlias01, chapPassword=, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, authType=eap, certSerialNumber=, nasPortType=Wireless-802.11)
20:36.198+08:00 INFO 48897 --- [nio-8080-exec-4] z.radius.service.RadiusAuthServiceImpl : [RADIUS 资产校验成功] username=owlias01

糟了:证书认证虽然通过了,但它存在两个很要命的问题:

  • 重读请求问题:一次 EAP-TLS 认证触发了多达 7 次 REST /check-asset 请求!原因是 EAP-TLS 是一个多回合的 TLS 握手过程。客户端和 RADIUS 服务器之间需要交换多个 EAP-TLS 报文(Client Hello -> Server Hello -> Certificate -> Key Exchange…)。 FreeRADIUS 的 authorize 模块在每一个 EAP 握手包到达时都会重新执行一遍。rest 模块被挂载在了 authorize 域(section)里,结果就是每进行一轮 TLS 握手,FreeRADIUS 就会调一次 Spring Boot 的 /check-asset 接口。对于这个问题,虽然可以在 REST 处使用 redis 进行去重处理,但是不够优雅!
  • 证书编号获取不到:因为是在 authorize 阶段嵌入的 REST,所以是拿不到证书编号的!这个问题更加致命,因为我们需要靠证书编号唯一定位一台设备(mac地址存在动态变动和伪造的问题),进而对资产进行精准的校验,甚至对证书进行动态吊销,在拿不到证书唯一编号的情况下,这一切都是妄想。

看来要对 REST 重构了!😭


对上述案例的完善(REST 后置)

问题和解决思路

上述企业案例中,我们将 REST 绑定到了 authorize 阶段 。存在的问题是:

  • 对于 PAP 或者 TTLS-PAP 认证,需要手动查库校验用户名密码;
  • 对于 CHAP 认证,需要手动从数据库中捞出 Cleartext-Password 再传递给 freeradius;
  • 对于 EAP-TLS 证书认证,问题更加致命,存在重复请求和获取不到证书序列号的问题。

为此,为了彻底解决上述问题,我们将 REST 绑定到 post-auth 阶段,这样做的好处:

  • 彻底解决 EAP 暴击问题:EAP-TLS / TTLS 无论中间交互多少个报文,post-auth 阶段只在 TLS 握手最终成功时触发 1 次,日志瞬间变干净,数据库压力暴降 80% 以上。
  • 提取到的证书属性更完整:在 EAP-TLS 的 authorize 初始阶段,证书还没发过来;只有握手结束进入 post-auth 时,FreeRADIUS 才能百分之百拿到客户端证书的序列号(TLS-Client-Cert-Serial)或 CN。
  • 防止无效资产校验:如果用户密码输错了,在 authorize 阶段调 REST 校验资产是纯粹浪费性能。放在 post-auth 能确保 “只有密码/证书是对的,才去查资产和发 VLAN”。


改造 FreeRadius 配置

第一,改造 rest 模块本身:/etc/freeradius/mods-enabled/rest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
rest {
# REST 服务器基础地址
connect_uri = "http://192.168.1.3:8080/api/v1/radius"

# 统一 HTTP 请求超时设置
tls {
timeout = 10
}

# ------------------------------------------------------------------
# 唯一保留的鉴权与 VLAN 下发入口 (post-auth 阶段调用)
# 无论是 EAP-TLS、EAP-TTLS、PAP 还是 CHAP,本地校验通过后都会触发此处
# ------------------------------------------------------------------
post-auth {
uri = "${..connect_uri}/check-asset"
method = 'post'
body = 'json'
data = '{\
"username": "%{User-Name}",\
"mac": "%{Calling-Station-Id}",\
"nasIp": "%{NAS-IP-Address}",\
"nasPort": "%{NAS-Port}",\
"nasPortType": "%{NAS-Port-Type}",\
"calledStationId": "%{Called-Station-Id}",\
"certSerialNumber": "%{TLS-Client-Cert-Serial}"\
}'

# 接收 Spring Boot 返回的 JSON,并将其自动解析为 RADIUS reply 属性
# 例如 Spring Boot 返回 {"Tunnel-Private-Group-Id": "200"}
response = 'json'
}

# ------------------------------------------------------------------
# 计费与代理相关 (根据业务需要保留/调整)
# ------------------------------------------------------------------
preacct {
uri = "${..connect_uri}/user/%{User-Name}/sessions/%{Acct-Unique-Session-ID}?action=preacct"
method = 'post'
tls = ${..tls}
}

accounting {
uri = "${..connect_uri}/user/%{User-Name}/sessions/%{Acct-Unique-Session-ID}?action=accounting"
method = 'post'
tls = ${..tls}
}

pre-proxy {
uri = "${..connect_uri}/user/%{User-Name}/mac/%{Called-Station-ID}?action=pre-proxy"
method = 'post'
tls = ${..tls}
}

post-proxy {
uri = "${..connect_uri}/user/%{User-Name}/mac/%{Called-Station-ID}?action=post-proxy"
method = 'post'
tls = ${..tls}
}

xlat {
body_uri_encode = yes
tls = ${..tls}
}

# ------------------------------------------------------------------
# REST 连接池配置
# ------------------------------------------------------------------
pool {
start = ${thread[pool].start_servers}
min = ${thread[pool].min_spare_servers}
max = ${thread[pool].max_servers}
spare = ${thread[pool].max_spare_servers}
uses = 0
retry_delay = 30
lifetime = 0
idle_timeout = 60
}
}

第二,改造主虚拟服务器逻辑:/etc/freeradius/sites-enabled/default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
server default {
listen {
type = auth
ipaddr = *
port = 1812
limit {
max_connections = 16
lifetime = 0
idle_timeout = 900
}
}

listen {
type = acct
ipaddr = *
port = 1813
}

# ------------------------------------------------------------------
# 1. 授权与预处理逻辑(纯本地/数据库拉取,0 次 HTTP 请求)
# ------------------------------------------------------------------
authorize {
filter_username
preprocess

# 哑终端 MAC 准入识别:如果 User-Name 等于 MAC 地址,标记为 MAC 认证
if (User-Name == Calling-Station-Id) {
update control {
Auth-Type := Accept
}
}

# 优先处理 EAP (EAP-TLS / EAP-TTLS)
eap {
ok = return
}

# 从数据库 radcheck 表拉取凭据属性
# - PAP 账号拉取到 Crypt-Password := $2b$12$...
# - CHAP 账号拉取到 Cleartext-Password := 1234567
sql

# FreeRADIUS 原生模块自动识别 control 列表中的密码类型并绑定路由
pap
chap
}

# ------------------------------------------------------------------
# 2. 凭据比对认证逻辑(完全由 FreeRADIUS C 原生模块高性能处理)
# ------------------------------------------------------------------
authenticate {
# 1. PAP 认证:原生 pap 模块识别 Crypt-Password,并在本地完成 BCrypt 运算比对
Auth-Type PAP {
pap
}

# 2. CHAP 认证:原生 chap 模块识别 Cleartext-Password,并在本地完成 MD5 运算比对
Auth-Type CHAP {
chap
}

# 3. EAP 框架认证 (EAP-TLS / EAP-TTLS)
eap

# 4. MAC 免密认证通过
Auth-Type Accept {
pap
}
}

# ------------------------------------------------------------------
# 3. 计费预处理阶段
# ------------------------------------------------------------------
preacct {
preprocess
acct_unique
suffix
files
}

# ------------------------------------------------------------------
# 4. 计费处理阶段
# ------------------------------------------------------------------
accounting {
detail
-sql
exec
attr_filter.accounting_response
}

session {
}

# ------------------------------------------------------------------
# 5. 认证通过后处理阶段(全局唯一 REST 调用点:资产鉴权与动态 VLAN)
# ------------------------------------------------------------------
post-auth {
# 除了像 TTLS-PAP 这种内层已经调用了 REST 的认证方式(在 inner-tunnel 配置),其他 PAP/CHAP/EAP-TLS证书等,统一调用 Spring Boot REST 服务
# EAP-TLS(纯证书):没有内层隧道,因此在外层 default 中由 REST 触发校验。
# REST 负责校验设备资产或证书状态,并返回动态 Tunnel-Private-Group-Id (VLAN ID)
if (!EAP-Type || EAP-Type == TLS) {
# 只有在非 EAP 认证(PAP/CHAP/MAC)或纯 EAP-TLS 时,才在外层触发 REST
rest
}

# 原生 Session 与属性恢复逻辑
if (session-state:User-Name && reply:User-Name && request:User-Name && (reply:User-Name == request:User-Name)) {
update reply {
&User-Name !* ANY
}
}

# 关键:将 inner-tunnel 传上来的 VLAN 等属性(存储在 session-state 中)合并到 reply 中
update {
&reply: += &session-state:
}

# 如果 REST 返回了 VLAN ID,补充 RFC 3580 802.1Q 标准 VLAN 属性
if (reply:Tunnel-Private-Group-Id) {
update reply {
&Tunnel-Type := VLAN
&Tunnel-Medium-Type := IEEE-802
}
}

-sql
exec

# EAP 握手收尾与密钥派生
eap

remove_reply_message_if_eap

Post-Auth-Type REJECT {
-sql
attr_filter.access_reject
eap
remove_reply_message_if_eap
}

Post-Auth-Type Challenge {
}

Post-Auth-Type Client-Lost {
}

if (EAP-Key-Name && &reply:EAP-Session-Id) {
update reply {
&EAP-Key-Name := &reply:EAP-Session-Id
}
}
}

pre-proxy {
}

post-proxy {
}
}

第三,改造内层隧道逻辑:/etc/freeradius/sites-enabled/inner-tunnel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
server inner-tunnel {

listen {
ipaddr = 127.0.0.1
port = 18120
type = auth
}

# ------------------------------------------------------------------
# 1. 内层授权与预处理阶段
# ------------------------------------------------------------------
authorize {
filter_username
suffix

# 支持 EAP 内部协商
eap {
ok = return
}

# 内层同样直接从数据库 radcheck 表加载密码凭据
sql

# 原生模块处理属性绑定
pap
chap

files
}

# ------------------------------------------------------------------
# 2. 内层凭据比对认证阶段
# ------------------------------------------------------------------
authenticate {
# EAP-TTLS 内层 PAP:直接走原生 pap 模块在本地比对 BCrypt
Auth-Type PAP {
pap
}

# EAP-TTLS 内层 CHAP:走原生 chap 模块在本地比对 MD5
Auth-Type CHAP {
chap
}

Auth-Type MS-CHAP {
mschap
}

mschap
eap
}

session {
}

# ------------------------------------------------------------------
# 3. 内层认证通过后处理阶段
# ------------------------------------------------------------------
post-auth {
# EAP-TTLS 内层账号密码通过后,在此调用 REST 校验资产并领取 VLAN
rest

-sql

# 如果内层 REST 获取到了 VLAN ID,将其同步到 outer.session-state
# 供外层 default 站点的 post-auth 统一下发给 NAS (交换机/AP)
if (reply:Tunnel-Private-Group-Id) {
update outer.session-state {
&Tunnel-Private-Group-Id := "%{reply:Tunnel-Private-Group-Id}"
&Tunnel-Type := VLAN
&Tunnel-Medium-Type := IEEE-802
}
}

# 保留原生的 session-state 传导机制
update {
&outer.session-state: += &reply:
}

Post-Auth-Type REJECT {
-sql
attr_filter.access_reject

update outer.session-state {
&Module-Failure-Message := &request:Module-Failure-Message
}
}
}

pre-proxy {
}

post-proxy {
}
}


REST 服务改造

依赖配置精简,暂时用不到 jdbc、加解密之类的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.3.0</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<optional>true</optional>
</dependency>
</dependencies>

业务类控制器改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Slf4j
@RestController
@RequestMapping("/api/v1/radius")
public class RadiusController {

@Resource
private RadiusAssetService radiusAssetService;

/**
* 统一资产校验与动态 VLAN 决策接口 (对应 post-auth 阶段)
* 走到这里的请求,说明用户凭据(TLS 证书 / BCrypt 密码 / CHAP MD5)在 FreeRADIUS 本地已 100% 校验通过!
*/
@PostMapping("/check-asset")
public ResponseEntity<RadiusAssetCheckResp> checkAssetAndGetVlan(@RequestBody RadiusAssetCheckReq req) {
log.info("[RADIUS Post-Auth] 收到资产鉴权请求req: {}", req);
try {
// 调用业务逻辑层做资产校验并获取分配的 VLAN
String vlanId = radiusAssetService.validateAssetAndAssignVlan(req);
log.info("[RADIUS Post-Auth] 资产校验通过 | 用户: {} | 匹配 VLAN: {}", req.getUsername(), vlanId);
// 返回成功,带上动态 VLAN 属性
RadiusAssetCheckResp resp = RadiusAssetCheckResp.builder()
.tunnelPrivateGroupId(vlanId)
.build();
return ResponseEntity.ok(resp);
} catch (AssetAccessDeniedException e) {
log.warn("[RADIUS Post-Auth] 资产校验不通过 | 用户: {} | 原因: {}", req.getUsername(), e.getMessage());
// 资产校验失败时返回 401 或 403 HTTP 状态码,FreeRADIUS 会将其识别为 reject 并拒绝接入
RadiusAssetCheckResp resp = RadiusAssetCheckResp.builder()
.replyMessage(e.getMessage())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(resp);
}
}

// 其他支持...
@PostMapping("/user/{username}/sessions/{sessionId}")
public ResponseEntity<?> handleSessionEvent(
@PathVariable("username") String username,
@PathVariable("sessionId") String sessionId,
@RequestParam("action") String action,
@RequestBody(required = false) Map<String, Object> body) {
log.info("[RADIUS 会话事件] action={}, username={}, sessionId={}, body={}", action, username, sessionId, body);
// 可根据 action ("preacct", "accounting") 处理计费日志记录、计算流量、记录上线/下线
return ResponseEntity.ok().build();
}

@PostMapping("/user/{username}/mac/{mac}")
public ResponseEntity<?> handleMacEvent(
@PathVariable("username") String username,
@PathVariable("mac") String mac,
@RequestParam("action") String action,
@RequestBody(required = false) Map<String, Object> body) {
log.info("[RADIUS 设备事件] action={}, username={}, mac={}, body={}", action, username, mac, body);
// 可根据 action ("post-auth", "pre-proxy", "post-proxy") 记录上下线审计审计日志
return ResponseEntity.ok().build();
}
}

校验入参和响应定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Data
public class RadiusAssetCheckReq {

/**
* 认证用户名(可能是 PAP/CHAP 账号,也可能是 EAP 证书的主体或内层账号)
*/
private String username;

/**
* 终端 MAC 地址 (Calling-Station-Id)
*/
private String mac;

/**
* 纳管设备/网络设备 IP (NAS-IP-Address)
*/
private String nasIp;

/**
* 纳管设备端口 (NAS-Port)
*/
private String nasPort;

/**
* 纳管端口类型 (NAS-Port-Type,如 Wireless-802.11 或 Ethernet)
*/
private String nasPortType;

/**
* NAS 的 SSID 或接收端标识 (Called-Station-Id)
*/
private String calledStationId;

/**
* EAP-TLS 客户端证书序列号(如果是 EAP-TLS 认证)
*/
private String certSerialNumber;
}


@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RadiusAssetCheckResp {

/**
* 动态下发的 802.1Q VLAN ID (如 "200")
* 映射 FreeRADIUS 中的 reply:Tunnel-Private-Group-Id 属性
*/
@JsonProperty("Tunnel-Private-Group-Id")
private String tunnelPrivateGroupId;

/**
* 可选:如果拒绝接入,可以返回具体的拒绝提示信息
* 映射 FreeRADIUS 中的 reply:Reply-Message
*/
@JsonProperty("Reply-Message")
private String replyMessage;
}
1
2
3
4
5
public class AssetAccessDeniedException extends RuntimeException {
public AssetAccessDeniedException(String message) {
super(message);
}
}

业务类实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j
@Service
public class RadiusAssetService {

public String validateAssetAndAssignVlan(RadiusAssetCheckReq req) {
// 1. 哑终端/MAC 准入场景判断
if (req.getUsername() != null && req.getUsername().equalsIgnoreCase(req.getMac())) {
return processMacAccess(req);
}

// 2. EAP-TLS 证书认证场景判断
if (StringUtils.hasText(req.getCertSerialNumber())) {
return processCertAccess(req);
}

// 3. 普通账号密码(PAP / CHAP / EAP-TTLS)场景判断
return processUserAccess(req);
}

private String processMacAccess(RadiusAssetCheckReq req) {
// 示例:查询打印机/摄像头等哑终端资产表
// boolean exists = macAssetRepository.existsByMacAndStatus(req.getMac(), "ACTIVE");
// if (!exists) throw new AssetAccessDeniedException("Mac address not in white list");

return "300"; // 哑终端网段 VLAN
}

private String processCertAccess(RadiusAssetCheckReq req) {
// 示例:校验证书序列号是否在企业资产库中绑定了合法设备
// if (certIsRevoked(req.getCertSerialNumber())) throw new AssetAccessDeniedException("Certificate revoked");

return "100"; // 办公高权限网段 VLAN
}

private String processUserAccess(RadiusAssetCheckReq req) {
// 示例:校验用户设备绑定逻辑或根据部门分配 VLAN
// User user = userRepository.findByUsername(req.getUsername());
// if (user.isDisabled()) throw new AssetAccessDeniedException("User account disabled");

// 假设研发部切 VLAN 200,普通部门切 VLAN 10
return "200";
}
}


测试验证

测试环境搭建参考:《Freeradius 3.2.10 环境搭建以及 PAP 和 CHAP 两种认证方式的测试 - 搭建基本的调试环境》

再次测试对 PAP 认证的支持,在 radius-client 执行:

1
2
3
4
5
$ echo "User-Name = owlias01, User-Password = 123456, Calling-Station-Id = 00-11-22-33-44-55, NAS-IP-Address = 172.18.0.2" | radclient -x 172.18.0.3:1812 auth testing123

# REST 服务日志输出
23:56:09.463+08:00 INFO 50911 --- [nio-8080-exec-5] z.radius.controller.RadiusController : [RADIUS Post-Auth] 收到资产鉴权请求req: RadiusAssetCheckReq(username=owlias01, mac=00-11-22-33-44-55, nasIp=172.18.0.2, nasPort=, nasPortType=, calledStationId=, certSerialNumber=)
23:56:09.465+08:00 INFO 50911 --- [nio-8080-exec-5] z.radius.controller.RadiusController : [RADIUS Post-Auth] 资产校验通过 | 用户: owlias01 | 匹配 VLAN: 200

再次测试对 CHAP 认证的支持,在 radius-client 执行:

1
2
3
4
5
$ radtest -t chap owlias_chap 1234567 172.18.0.3:1812 0 testing123

# REST 服务日志输出
23:58:33.513+08:00 INFO 50911 --- [nio-8080-exec-8] z.radius.controller.RadiusController : [RADIUS Post-Auth] 收到资产鉴权请求req: RadiusAssetCheckReq(username=owlias_chap, mac=, nasIp=172.18.0.2, nasPort=0, nasPortType=, calledStationId=, certSerialNumber=)
23:58:33.513+08:00 INFO 50911 --- [nio-8080-exec-8] z.radius.controller.RadiusController : [RADIUS Post-Auth] 资产校验通过 | 用户: owlias_chap | 匹配 VLAN: 200

再次测试对 EAP-TTLS+PAP 认证的支持:也可以参考 《基于密码的企业级安全认证实现 - 测试验证》

1
2
3
4
5
$ eapol_test -c ttls-pap.conf -a 172.18.0.3 -p 1812 -s testing123

# REST 服务日志输出
23:59:45.529+08:00 INFO 50911 --- [nio-8080-exec-3] z.radius.controller.RadiusController : [RADIUS Post-Auth] 收到资产鉴权请求req: RadiusAssetCheckReq(username=owlias01, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, nasPortType=Wireless-802.11, calledStationId=, certSerialNumber=)
23:59:45.529+08:00 INFO 50911 --- [nio-8080-exec-3] z.radius.controller.RadiusController : [RADIUS Post-Auth] 资产校验通过 | 用户: owlias01 | 匹配 VLAN: 200

再次测试对 EAP-TLS 证书认证的支持:看到日志中也获取到了 certSerialNumber 证书编号!并且请求只有一次,世界又重新回归了美好!测试参考 《配置文件说明以及一个企业级网络认证案例 - 测试验证》。(注意,证书认证依赖的是证书之间的握手,所以它在认证的时候也是不需要查询数据库的用户密码的)。

1
2
3
4
5
$ eapol_test -c eap-tls.conf -a 172.18.0.3 -p 1812 -s testing123

# REST 服务日志输出
00:01:42.655+08:00 INFO 50911 --- [nio-8080-exec-2] z.radius.controller.RadiusController : [RADIUS Post-Auth] 收到资产鉴权请求req: RadiusAssetCheckReq(username=owlias01, mac=02-00-00-00-00-01, nasIp=127.0.0.1, nasPort=, nasPortType=Wireless-802.11, calledStationId=, certSerialNumber=02)
00:01:42.656+08:00 INFO 50911 --- [nio-8080-exec-2] z.radius.controller.RadiusController : [RADIUS Post-Auth] 资产校验通过 | 用户: owlias01 | 匹配 VLAN: 100

在 RADIUS 的设计中,MAC 认证本质上就是一个 PAP 认证,只不过它的用户名和密码(或者仅用户名)都是终端的 MAC 地址(专门适用于哑终端接入)。所以它本质上根本没有所谓的 “密码校验”,认证时也不需要查库。这个案例对 MAC 认证的资产校验也完美支持,在此也进行一并测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ echo "User-Name = '02-00-00-00-00-01', User-Password = '02-00-00-00-00-01', Calling-Station-Id = '02-00-00-00-00-01', NAS-IP-Address = 192.168.1.1, NAS-Port-Type = Ethernet" | radclient -x 172.18.0.3:1812 auth testing123

Sent Access-Request Id 94 from 0.0.0.0:41337 to 172.18.0.3:1812 length 122
User-Name = "02-00-00-00-00-01"
User-Password = "02-00-00-00-00-01"
Calling-Station-Id = "02-00-00-00-00-01"
NAS-IP-Address = 192.168.1.1
NAS-Port-Type = Ethernet
Cleartext-Password = "02-00-00-00-00-01"
Received Access-Accept Id 94 from 172.18.0.3:1812 to 172.18.0.2:41337 length 55
Message-Authenticator = 0x859ffef171be35027f1b74bfcd35a30c
Tunnel-Private-Group-Id:0 = "300"
Tunnel-Type:0 = VLAN
Tunnel-Medium-Type:0 = IEEE-802